SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
9.8 KB · 183 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { ExternalLink, AlertTriangle } from 'lucide-react';5import { PageHeader, Section, KV, Note } from '@/components/ui/section';6import { Badge, ClaimBadge } from '@/components/ui/badge';7import { EmptyState } from '@/components/ui/empty-state';8import { Freshness } from '@/components/ui/freshness';9import { SourceBadge } from '@/components/ui/source-badge';10import { EvidenceTable } from '@/components/data/evidence-table';11import { Pager } from '@/components/ui/pager';12import { getPublicationByPmid, publicationEntities } from '@/lib/queries/publications';13import { evidenceForPublication, evidenceForPublicationCount, EVIDENCE_PAGE_SIZE } from '@/lib/queries/evidence';14import { loadProvenance } from '@/lib/queries/provenance';15import { fmtDate, fmtInt, humanize, truncate } from '@/lib/format';16import { pageInfo } from '@/lib/pagination';17import { int, type SP } from '@/lib/search-params';1819export const revalidate = 3600;2021export async function generateMetadata({ params }: { params: Promise<{ pmid: string }> }): Promise<Metadata> {22  const p = await getPublicationByPmid((await params).pmid);23  return p ? { title: `${truncate(p.title, 80)} — PMID ${p.pmid}`, description: truncate(p.abstract ?? p.title, 160), robots: p.retracted ? { index: false } : undefined, alternates: { canonical: `/publication/${p.pmid}` } } : { title: 'Publication' };24}2526export default async function PublicationPage({ params, searchParams }: { params: Promise<{ pmid: string }>; searchParams: Promise<SP> }) {27  const { pmid } = await params;28  if (!/^\d{1,10}$/.test(pmid)) notFound();29  const p = await getPublicationByPmid(pmid);30  if (!p) notFound();31  const sp = await searchParams;32  const evTotal = await evidenceForPublicationCount(pmid);33  const ev = pageInfo(int(sp, 'evPage', 1, 1, 100_000), EVIDENCE_PAGE_SIZE, evTotal);34  const [edges, evidence] = await Promise.all([publicationEntities(p.id), evTotal ? evidenceForPublication(pmid, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([])]);35  const prov = await loadProvenance(evidence.map((e) => e.provenance_id));36  const validated = edges.filter((e) => e.status === 'validated');37  const candidates = edges.filter((e) => e.status === 'candidate');3839  return (40    <article>41      {p.retracted ? (42        <div role="alert" className="mt-4 flex items-start gap-2 border border-danger bg-danger-soft px-4 py-3 text-[13.5px] text-danger">43          <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />44          <div>45            <p className="font-medium">This publication has been retracted.</p>46            {p.retraction_notice ? <p className="mt-0.5">{p.retraction_notice}</p> : null}47            <p className="mt-0.5">Evidence relying on it is flagged; treat all findings below as withdrawn.</p>48          </div>49        </div>50      ) : null}51      <PageHeader kicker={`Publication${p.is_preprint ? ' · preprint' : ''}`} title={p.title}>52        <p className="mt-2 text-[13.5px] text-ink-2">53          {p.authors.map((a) => a.name).join(', ') || 'Authors not recorded'}54        </p>55        <div className="mt-1 flex flex-wrap items-center gap-2 text-[12.5px] text-ink-3">56          {p.journal ? <span className="italic">{p.journal}</span> : null}57          {p.pub_date ? <span>{fmtDate(p.pub_date)}</span> : p.pub_year ? <span>{p.pub_year}</span> : null}58          <span className="ci-mono">PMID {p.pmid}</span>59          {p.doi ? (60            <a className="ci-link ci-mono inline-flex items-center gap-1" href={`https://doi.org/${p.doi}`} target="_blank" rel="noopener noreferrer">61              doi:{p.doi} <ExternalLink className="h-3 w-3" aria-hidden />62            </a>63          ) : null}64          {p.pmcid ? <span className="ci-mono">{p.pmcid}</span> : null}65          {p.publication_types.map((t) => (66            <Badge key={t} tone="outline">67              {t}68            </Badge>69          ))}70          <SourceBadge p={{ sourceSlug: 'pubmed', sourceName: 'PubMed', retrievedAt: p.updated_at, ingestRunId: p.ingest_run_id }} />71          <ClaimBadge kind="published" />72        </div>73      </PageHeader>7475      <div className="grid gap-8 lg:grid-cols-[1fr_320px]">76        <div className="space-y-8">77          <Section id="abstract" kicker="Abstract" title="Abstract (excerpt)" description="Only the opening of the abstract is shown; abstract text may carry publisher copyright.">78            {p.abstract ? (79              <>80                <p className="max-w-3xl text-[14.5px] leading-relaxed">{truncate(p.abstract, 300)}</p>81                <a className="ci-link mt-2 inline-flex items-center gap-1 text-[13.5px]" href={`https://pubmed.ncbi.nlm.nih.gov/${p.pmid}/`} target="_blank" rel="noopener noreferrer">82                  Read on PubMed <ExternalLink className="h-3 w-3" aria-hidden />83                </a>84              </>85            ) : (86              <EmptyState compact>87                No abstract stored.{' '}88                <a className="ci-link" href={`https://pubmed.ncbi.nlm.nih.gov/${p.pmid}/`} target="_blank" rel="noopener noreferrer">89                  Read on PubMed90                </a>91              </EmptyState>92            )}93          </Section>9495          <Section id="entities" kicker="Linked entities" title={`Linked entities (${fmtInt(edges.length)})`} description="How each link was made (MeSH, dictionary, registry reference, curation…) and whether it has been validated. Candidate links are not counted in entity statistics.">96            {edges.length ? (97              <div className="space-y-4">98                {[99                  { label: 'Validated', rows: validated, tone: 'ok' as const },100                  { label: 'Candidate', rows: candidates, tone: 'warn' as const },101                ]102                  .filter((g) => g.rows.length)103                  .map((g) => (104                    <div key={g.label}>105                      <p className="mb-1 flex items-center gap-2">106                        <Badge tone={g.tone}>{g.label}</Badge> <span className="ci-num text-[12px] text-ink-3">{g.rows.length}</span>107                      </p>108                      <ul className="flex flex-wrap gap-1.5 text-[13.5px]">109                        {g.rows.map((e) => (110                          <li key={`${e.entity_type}-${e.entity_id}-${e.method}`} className="inline-flex items-center gap-1.5 border border-rule px-2 py-0.5">111                            <span className="ci-kicker">{e.entity_type}</span>112                            {e.href ? (113                              <Link className="ci-link" href={e.href}>114                                {e.label ?? e.entity_id}115                              </Link>116                            ) : (117                              <span>{e.label ?? e.entity_id}</span>118                            )}119                            <span className="ci-mono text-[10.5px] text-ink-3">{e.method}</span>120                            {e.confidence != null ? <span className="ci-num text-[10.5px] text-ink-3">{e.confidence.toFixed(2)}</span> : null}121                          </li>122                        ))}123                      </ul>124                    </div>125                  ))}126              </div>127            ) : (128              <EmptyState compact>No entity link recorded for this publication.</EmptyState>129            )}130            {p.nct_ids.length ? (131              <p className="mt-3 text-[13px]">132                <span className="ci-kicker mr-2">Registered trials</span>133                {p.nct_ids.map((n) => (134                  <Link key={n} className="ci-mono ci-link mr-2" href={`/trial/${n}`}>135                    {n}136                  </Link>137                ))}138              </p>139            ) : null}140          </Section>141142          <Section id="evidence" kicker="Curated evidence" title={`Evidence citing this paper (${fmtInt(evTotal)})`} description={evTotal > EVIDENCE_PAGE_SIZE ? `${EVIDENCE_PAGE_SIZE} items per page.` : undefined}>143            {evidence.length ? (144              <>145                <EvidenceTable146                  items={evidence}147                  prov={prov}148                  showCancer149                  summary={150                    <>151                      Showing {fmtInt(ev.from)}–{fmtInt(ev.to)} of {fmtInt(evTotal)} evidence items152                    </>153                  }154                />155                <Pager total={evTotal} pageSize={ev.pageSize} page={ev.page} hrefFor={(pg) => `/publication/${p.pmid}${pg > 1 ? `?evPage=${pg}` : ''}#evidence`} label="Evidence pages" noun="evidence items" />156              </>157            ) : (158              <EmptyState compact>No curated evidence item cites this publication.</EmptyState>159            )}160          </Section>161        </div>162163        <aside className="space-y-8">164          <Section id="record" kicker="Record" title="Metadata" level={3}>165            <KV166              items={[167                { k: 'CancerIndex ID', v: <span className="ci-mono">{p.id}</span> },168                { k: 'Language', v: p.language },169                { k: 'Cited by', v: p.cited_by_count != null ? <span className="ci-num">{fmtInt(p.cited_by_count)}</span> : null },170                { k: 'MeSH major', v: p.mesh_terms.filter((m) => m.major).map((m) => m.descriptor).join('; ') || null },171                { k: 'MeSH other', v: p.mesh_terms.filter((m) => !m.major).slice(0, 15).map((m) => m.descriptor).join('; ') || null },172              ]}173            />174            <Freshness dataUpdatedAt={p.updated_at} sourceUpdatedAt={p.pub_date} extra="source: pubmed" />175          </Section>176          <Note>Bibliographic data from PubMed (NLM). CancerIndex stores abstracts for indexing only and displays a short excerpt with a link to the record.</Note>177          <p className="text-[12px] text-ink-3">Publication type: {p.publication_types.map(humanize).join(', ') || 'not recorded'}.</p>178        </aside>179      </div>180    </article>181  );182}183